// ...existing code...
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const bcrypt = require('bcrypt');
const path = require('path');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const Database = require('better-sqlite3');
const { execFile } = require('child_process');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');

const app = express();
const server = http.createServer(app);
const io = socketIo(server);

const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;

// Deepseek / Ollama integration settings (configurable via env)


const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-r1:7b';
const OLLAMA_CLI_PATH = process.env.OLLAMA_CLI_PATH || 'ollama'; // assume 'ollama' is in PATH
// Optional Ollama host (default to local Ollama default port 11434)
// Format: 'host:port' or full URL; override with environment variable if needed.
const OLLAMA_HOST = process.env.OLLAMA_HOST || '127.0.0.1:11434';
const DEEPSEEK_MAX_HISTORY = Number(process.env.DEEPSEEK_MAX_HISTORY) || 20;
const DEEPSEEK_TIMEOUT_MS = Number(process.env.DEEPSEEK_TIMEOUT_MS) || 20000;

// Cache for resolved actual model name (may be like 'deepseek-r1:7b')
let resolvedDeepseekModel = null;

// Resolve a model name by prefix using `ollama list` output. Returns the exact model name if found.
async function resolveOllamaModel(prefix) {
  return new Promise((resolve, reject) => {
  const execEnv = Object.assign({}, process.env);
  if (OLLAMA_HOST) execEnv.OLLAMA_HOST = OLLAMA_HOST;
  execFile(OLLAMA_CLI_PATH, ['list'], { timeout: 5000, maxBuffer: 200 * 1024, env: execEnv }, (err, stdout, stderr) => {
      if (err) return reject(err);
      const lines = String(stdout || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
      // The list output often has header row; find tokens that look like 'name\t...'
      for (const line of lines) {
        // skip header-like lines
        if (line.toLowerCase().startsWith('name') || line.toLowerCase().startsWith('id')) continue;
        const parts = line.split(/\s+/);
        const name = parts[0];
        if (!name) continue;
        if (name === prefix || name.startsWith(prefix)) return resolve(name);
      }
      return reject(new Error('no model matching prefix ' + prefix));
    });
  });
}

// Use SQLite DB stored on disk so data persists across restarts
const dbPath = path.join(__dirname, 'chatroom.db');
const db = new Database(dbPath);

// Initialize tables
db.prepare(`CREATE TABLE IF NOT EXISTS users (
  username TEXT PRIMARY KEY,
  password_hash TEXT NOT NULL
)`).run();

db.prepare(`CREATE TABLE IF NOT EXISTS messages (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT NOT NULL,
  message TEXT NOT NULL,
  timestamp TEXT NOT NULL
)`).run();

// Track online users: username -> Set of socket ids
const onlineUsers = new Map();
// Bot users that should always appear online
const botUsers = new Set(['deepseek']);

// Middleware
// Use built-in body parsers
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cookieParser());
app.use(session({
  secret: 'your-secret-key',
  resave: false,
  saveUninitialized: false
}));
// Authentication middleware: redirect unauthenticated users to /login for protected routes.
// Allow access to login/register pages, static asset files (by extension), and Socket.IO handshake path.
const allowedPaths = new Set(['/login', '/register', '/favicon.ico', '/whoami', '/history']);
// Allow debug endpoint through when enabled
if (process.env.DEBUG_DEEPSEEK) {
  allowedPaths.add('/debug/deepseek-trigger');
}

app.use((req, res, next) => {
  // quick allowlist checks
  if (allowedPaths.has(req.path) || req.path.startsWith('/socket.io')) return next();
  if (req.path.match(/\.(css|js|png|jpg|jpeg|svg|ico|woff2?|ttf|map)$/)) return next();
  if (req.session && req.session.user) return next();
  return res.redirect('/login');
});

// 静态资源路由
app.use(express.static('public'));

// Routes
const fileIndex = path.join(__dirname, 'public', 'index.html');

function userExists(username) {
  const row = db.prepare('SELECT 1 FROM users WHERE username = ?').get(username);
  return !!row;
}

app.get('/', (req, res) => {
  const current = req.session && req.session.user;
  if (!current) return res.redirect('/login');
  if (!userExists(current)) {
    req.session.destroy(() => res.redirect('/login'));
    return;
  }
  res.sendFile(fileIndex);
});


app.get('/register', (req, res) => {
  res.sendFile(__dirname + '/public/register.html');
});

app.get('/login', (req, res) => {
  res.sendFile(__dirname + '/public/login.html');
});

// Endpoint for frontend to determine current logged-in user (returns JSON)
app.get('/whoami', (req, res) => {
  res.json({ username: req.session && req.session.user ? req.session.user : null });
});

// Return full chat history for authenticated users
app.get('/history', (req, res) => {
  if (!(req.session && req.session.user)) return res.status(401).json({ error: 'unauthenticated' });
  const rows = db.prepare('SELECT id, username, message, timestamp FROM messages ORDER BY id ASC').all();
  return res.json(rows);
});

app.post('/register', async (req, res) => {
  const { username, password } = req.body;
  if (!username || !password) return res.redirect('/register?error=invalid');
  if (userExists(username)) return res.redirect('/register?error=exists');
  const password_hash = await bcrypt.hash(password, 10);
  try {
    db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(username, password_hash);
    req.session.user = username;
    return res.redirect('/');
  } catch (err) {
    console.error('Failed to insert user', err);
    return res.redirect('/register?error=exists');
  }
});

app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  const user = db.prepare('SELECT username, password_hash FROM users WHERE username = ?').get(username);
  if (!user || !(await bcrypt.compare(password, user.password_hash))) {
    return res.redirect('/login?error=invalid');
  }
  req.session.user = user.username;
  res.redirect('/');
});

app.post('/logout', (req, res) => {
  req.session.destroy();
  res.redirect('/');
});

// Account configuration routes (change username / password)
app.get('/config', (req, res) => {
  if (!(req.session && req.session.user)) return res.redirect('/login');
  res.sendFile(__dirname + '/public/config.html');
});

app.post('/config/username', async (req, res) => {
  const current = req.session && req.session.user;
  const { newUsername } = req.body;
  if (!current) return res.redirect('/login');
  if (!newUsername) return res.redirect('/config?error=invalid');
  if (userExists(newUsername)) return res.redirect('/config?error=exists');
  try {
    const update = db.prepare('UPDATE users SET username = ? WHERE username = ?');
    const info = update.run(newUsername, current);
    if (info.changes === 0) return res.redirect('/config?error=notfound');
    // Update messages authored by the user to the new username
    db.prepare('UPDATE messages SET username = ? WHERE username = ?').run(newUsername, current);
    // Update session
    req.session.user = newUsername;
    return res.redirect('/config?success=username');
  } catch (err) {
    console.error('Username change failed', err);
    return res.redirect('/config?error=server');
  }
});

app.post('/config/password', async (req, res) => {
  const current = req.session && req.session.user;
  const { currentPassword, newPassword, confirmPassword } = req.body;
  if (!current) return res.redirect('/login');
  if (!currentPassword || !newPassword || newPassword !== confirmPassword) return res.redirect('/config?error=invalid');
  const user = db.prepare('SELECT password_hash FROM users WHERE username = ?').get(current);
  if (!user) return res.redirect('/config?error=notfound');
  if (!(await bcrypt.compare(currentPassword, user.password_hash))) return res.redirect('/config?error=badcurrent');
  try {
    const newHash = await bcrypt.hash(newPassword, 10);
    db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run(newHash, current);
    return res.redirect('/config?success=password');
  } catch (err) {
    console.error('Password change failed', err);
    return res.redirect('/config?error=server');
  }
});

// Edit message endpoint: only allow the author to edit their own message
app.post('/message/edit', (req, res) => {
  const user = req.session && req.session.user;
  const { id, newText } = req.body;
  if (!user) return res.status(401).json({ error: 'unauthenticated' });
  if (!id || typeof newText !== 'string') return res.status(400).json({ error: 'invalid' });
  const msg = db.prepare('SELECT id, username FROM messages WHERE id = ?').get(id);
  if (!msg) return res.status(404).json({ error: 'notfound' });
  if (msg.username !== user) return res.status(403).json({ error: 'forbidden' });
  try {
    db.prepare('UPDATE messages SET message = ? WHERE id = ?').run(newText, id);
    // notify clients about edit
    io.emit('message edited', { id, message: newText });
    return res.json({ ok: true });
  } catch (err) {
    console.error('Edit failed', err);
    return res.status(500).json({ error: 'server' });
  }
});

// Retract message endpoint: author can retract their message (mark as '[已撤回]')
app.post('/message/retract', (req, res) => {
  const user = req.session && req.session.user;
  const { id } = req.body;
  if (!user) return res.status(401).json({ error: 'unauthenticated' });
  if (!id) return res.status(400).json({ error: 'invalid' });
  const msg = db.prepare('SELECT id, username FROM messages WHERE id = ?').get(id);
  if (!msg) return res.status(404).json({ error: 'notfound' });
  // Allow the original author to retract, or allow any authenticated user to retract deepseek bot messages
  if (msg.username !== user && msg.username !== 'deepseek') return res.status(403).json({ error: 'forbidden' });
  try {
    db.prepare('UPDATE messages SET message = ? WHERE id = ?').run('[已撤回]', id);
    io.emit('message retracted', { id });
    return res.json({ ok: true });
  } catch (err) {
    console.error('Retract failed', err);
    return res.status(500).json({ error: 'server' });
  }
});

// Socket.IO
io.on('connection', (socket) => {
  console.log('User connected');

  // Emit online users including persistent bot users
  const emitOnline = () => {
    const combined = new Set([...onlineUsers.keys(), ...botUsers]);
    const list = Array.from(combined);
    io.emit('online users', list);
    console.log('online users updated ->', list);
  };

  const addOnline = (username) => {
    if (!username) return;
    socket.username = username;
    const set = onlineUsers.get(username) || new Set();
    set.add(socket.id);
    onlineUsers.set(username, set);
    emitOnline();
    const rows = db.prepare('SELECT username, message, timestamp FROM messages ORDER BY id ASC').all();
    socket.emit('message history', rows);
  };

  const removeOnline = () => {
    if (!socket.username) return;
    const set = onlineUsers.get(socket.username);
    if (!set) return;
    set.delete(socket.id);
    if (set.size === 0) onlineUsers.delete(socket.username);
    emitOnline();
  };

  socket.on('identify', addOnline);
  // Send current online users to the new socket, include persistent bot users
  socket.emit('online users', Array.from(new Set([...(onlineUsers.keys ? Array.from(onlineUsers.keys()) : []), ...botUsers])));

  socket.on('chat message', ({ username, message }) => {
    const timestamp = new Date().toISOString();
    try {
      const info = db.prepare('INSERT INTO messages (username, message, timestamp) VALUES (?, ?, ?)').run(username, message, timestamp);
      const id = info.lastInsertRowid || info.lastInsertROWID || info.lastInsertId || null;
      const msg = { id, username, message, timestamp };
      io.emit('chat message', msg);
      // If message mentions @deepseek, generate a bot reply using local Ollama (via CLI)
      try {
        if (typeof message === 'string' && /@deepseek\b/i.test(message)) {
          // run asynchronously, don't block the socket handler
          generateDeepseekReply(username, message).catch(err => console.error('Deepseek generation error:', err));
        }
      } catch (err) {
        console.error('Error scheduling deepseek reply', err);
      }
    } catch (err) {
      console.error('Failed to save message', err);
    }
  });

  socket.on('disconnect', () => {
    console.log('User disconnected');
    removeOnline();
  });
});

server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
  // Try to resolve a usable model name and report it
  resolveOllamaModel(DEEPSEEK_MODEL).then(real => {
    console.log('Deepseek model resolved to:', real);
    // cache
    resolvedDeepseekModel = real;
  }).catch(err => {
    console.warn('Could not resolve Deepseek model for prefix "' + DEEPSEEK_MODEL + '":', err && err.message ? err.message : err);
  });
  // Emit initial online users (include bots)
  setTimeout(() => {
    io.emit('online users', Array.from(new Set([...onlineUsers.keys(), ...botUsers])));
  }, 1000);
});

// Generate a reply from the deepseek model using the local ollama CLI and recent chat history.
async function generateDeepseekReply(triggerUsername, triggerMessage) {
  // Remove the @deepseek mention to form the user prompt
  const userMessage = triggerMessage.replace(/@deepseek\b/ig, '').trim();

  // 不参考历史，只用当前用户输入构建简单提示词
  const systemInstruction = `系统：你是 Deepseek，一个温暖随和的聊天伙伴，像朋友一样与人交谈。不要输出任何 Markdown 格式或代码块，不要解释你的思路。请以自然、会话化的语气回复，简短直接，3到5句为宜，总长度不超过100字，可适度使用表情。`;

  // 直接将用户的当前消息放入 prompt，模型只根据此 prompt 生成回复
  const prompt = [
    systemInstruction,
    '',
    `用户: ${userMessage}`,
    'Deepseek:'
  ].join('\n');

  // Helpers: run ollama with prompt written to a temp file, and persist/broadcast the bot message.
  async function runOllamaPrompt(promptText) {
    const modelToUse = resolvedDeepseekModel || DEEPSEEK_MODEL;
    const tmpDir = os.tmpdir();
    const tmpName = `deepseek_prompt_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.txt`;
    const tmpPath = path.join(tmpDir, tmpName);

    try {
      fs.writeFileSync(tmpPath, promptText, { encoding: 'utf8' });
    } catch (err) {
      throw new Error('failed to write prompt temp file: ' + String(err));
    }

    const safePath = tmpPath.replace(/'/g, "'\\''");
    const shellCmd = `${OLLAMA_CLI_PATH} run ${modelToUse} < '${safePath}'`;
    console.log('Running ollama ->', shellCmd.slice(0, 200), '...');

    try {
      const execEnv = Object.assign({}, process.env);
      if (OLLAMA_HOST) execEnv.OLLAMA_HOST = OLLAMA_HOST;
      console.log('Running ollama with OLLAMA_HOST=', OLLAMA_HOST || '(local)');
      const { stdout, stderr } = await new Promise((resolve, reject) => {
        execFile('sh', ['-c', shellCmd], { timeout: DEEPSEEK_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, env: execEnv }, (err, stdout, stderr) => {
          if (err) return reject({ err, stderr });
          resolve({ stdout: stdout || '', stderr: stderr || '' });
        });
      });
      console.log('Ollama stdout length', String(stdout).length, 'stderr length', String(stderr).length);
  // Return the full stdout (trimmed) so we persist and broadcast the model's complete reply
  const replyText = String(stdout || '').trim();
  console.log('Deepseek full reply length', replyText.length);
  return replyText;
    } finally {
      try { fs.unlinkSync(tmpPath); } catch (e) { /* ignore */ }
    }
  }

  // Sanitize model output: remove markdown and obvious chain-of-thought / reasoning traces
  function sanitizeDeepseekReply(text) {
  // Keep it minimal: only remove any content between 'Thinking' and 'done thinking' (inclusive).
  if (!text) return '';
  let s = String(text);
    // remove any content starting from 'Thinking... ' up to and including ' ...done thinking.' (case-insensitive)
    // this matches the exact dots and spaces pattern specified
    s = s.replace(/thinking\.\.\.\s[\s\S]*?\s\.\.\.done thinking\./ig, '');
  return s.trim();
  }
  async function persistAndBroadcast(replyText) {
    const ts = new Date().toISOString();
    const info = db.prepare('INSERT INTO messages (username, message, timestamp) VALUES (?, ?, ?)').run('deepseek', replyText, ts);
    const id = info.lastInsertRowid || null;
    const botMsg = { id, username: 'deepseek', message: replyText, timestamp: ts };
    console.log('Deepseek generated message id=', id, 'len=', String(replyText || '').length);
    io.emit('chat message', botMsg);
    console.log('Deepseek broadcast sent');
    return botMsg;
  }

  // Run model and persist result
  try {
    const reply = await runOllamaPrompt(prompt);
    if (!reply) throw new Error('empty reply from model');
    const cleaned = sanitizeDeepseekReply(reply);
    if (!cleaned) throw new Error('empty reply after sanitization');
    return await persistAndBroadcast(cleaned);
  } catch (err) {
    console.error('Deepseek generation failed:', err && err.err ? err.err : err);
    throw err;
  }
}

// Debug endpoint to trigger deepseek generation manually. Only enabled when DEBUG_DEEPSEEK=1
app.post('/debug/deepseek-trigger', express.json(), async (req, res) => {
  if (!process.env.DEBUG_DEEPSEEK) return res.status(404).send('not enabled');
  const { username, message } = req.body || {};
  if (!username || !message) return res.status(400).json({ error: 'missing' });
  try {
    const bot = await generateDeepseekReply(username, message);
    return res.json({ ok: true, bot });
  } catch (err) {
    console.error('Debug trigger failed', err);
    return res.status(500).json({ error: String(err && err.message ? err.message : err) });
  }
});
